Day 18 開始要一個一個把掃描 Task 接進 Pipeline。動手之前先把順序定下來。
Tekton 用 DAG 描述 Task 之間的依賴,runAfter 決定誰等誰。這篇要說明的是:在這條流水線裡,有兩個地方的順序不是效率選擇,是正確性條件——排錯了,掃描與簽章仍然會成功,但它們代表的意思會變。
這是設計說明,不是操作步驟。完整的 Task 實作從 Day 18 起逐篇處理。
| 常見的做法 | 這條流水線的做法 |
|---|---|
| 能並行的全部並行,追求 CI 速度 | 前段盡量並行,但有兩處刻意串行 |
| 掃描跟簽章互不相干,可以同時做 | 簽章必須等掃描結果,否則簽章的語意會失效 |
| 推完 image 就可以先更新 GitOps | 更新必須等簽章完成,否則部署會被准入控制擋掉 |
| Task 失敗會中止整條 Pipeline | Tekton 只擋「還沒開始」的下游,已經在跑的不會被中止 |
定期檢查 runAfter 就能確保順序防線有效 |
還要檢查有沒有 onError: continue——它不改 runAfter 就能繞過 |
第四列是前三列的成因,第 1 節有實測與上游依據。
CRC 2.61.0+6eb443
OpenShift 4.21.14 / Kubernetes v1.34.6(單節點 crc)
Red Hat OpenShift Pipelines Operator 1.23.1
Tekton API:Pipeline / PipelineRun / Task / TaskRun 為 tekton.dev/v1
第 1 節的失敗語意自 Tekton Pipeline upstream v0.14.0 起就是設計行為,不是這個版本的特例;本文在 1.23.1 上實測確認。
第 3 節的耗時數字與第 4 節的 Kyverno 錯誤訊息取自另一套已經跑完整條 Pipeline 的舊環境,這台重現不出來,各處都有標注。
這一節是後面兩條邊界的基礎。
Task 失敗時,Tekton 的處理是:
這不是觀察到的巧合,是寫進 release note 的行為。Tekton Pipeline v0.14.0:
Task 失敗或取消時,pipeline run 停止排程新的 Task。狀態要等所有已排程的 TaskRun 完成才標記為失敗。新增 reason
PipelineRunStopping,表示 pipeline run 發現失敗、正在等待 TaskRun 完成。
Stopping 這個字在 Tekton 裡語意固定。手動優雅停止的 StoppedRunFinally 定義是「已產生的 TaskRun 正常完成(含 retries),但不再排程新的非 finally task」——同一套語意。
用一個拋棄式 PipelineRun 驗:fail-fast 兩秒後失敗,long-runner 與它並行跑 30 秒,downstream 排在 fail-fast 之後。
apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
generateName: probe-failfast-
namespace: ci
spec:
pipelineSpec:
tasks:
- name: fail-fast
taskSpec:
steps:
- name: fail
image: registry.access.redhat.com/ubi9/ubi-minimal:latest
script: |
sleep 2
exit 1
- name: long-runner
taskSpec:
steps:
- name: sleep
image: registry.access.redhat.com/ubi9/ubi-minimal:latest
script: |
sleep 30
echo FINISHED_ANYWAY
- name: downstream
runAfter: [fail-fast]
taskSpec:
steps:
- name: never
image: registry.access.redhat.com/ubi9/ubi-minimal:latest
script: echo SHOULD_NOT_RUN
結果:
TASK STATUS START END
fail-fast StepFailed 2026-08-14T21:11:09Z 2026-08-14T21:12:18Z
long-runner Succeeded 2026-08-14T21:11:09Z 2026-08-14T21:12:57Z
long-runner 在 fail-fast 結束後又跑了 39 秒才完成,log 有 FINISHED_ANYWAY。downstream 查不到 TaskRun。
不必從行為反推,Tekton 自己在狀態裡講明了。
訊號一:狀態訊息區分 Failed 與 Cancelled。
Tasks Completed: 2 (Failed: 1, Cancelled 0), Skipped: 1
Cancelled 0——沒有任何 Task 被取消。
訊號二:中間狀態的 reason 是 PipelineRunStopping。 失敗之後、long-runner 還在跑的那段時間:
Unknown / PipelineRunStopping
Tasks Completed: 1 (Failed: 1, Cancelled 0), Incomplete: 1, Skipped: 1
Stopping 而不是 Cancelling,加上 Incomplete: 1。
這個訊號在 pipeline 含
finally時可能不出現(tektoncd/pipeline #3119,2020 年回報,1.23.1 修沒修未查)。上面的探測沒有finally。之後加了finally就改看訊號一與訊號三,那兩個不受影響。
訊號三:skippedTasks 是獨立欄位。
& $OC --kubeconfig $kc get pipelinerun <run> -n ci -o jsonpath='{.status.skippedTasks[*].name}'
# downstream
childReferences 裡只有 fail-fast 與 long-runner。downstream 不是建了又刪,是從來沒有被建立。
這個
sleep 2; exit 1的 Task 花了 69 秒才結束——ubi-minimal沒有快取,要先拉 image。做時序驗證時要把拉取時間算進去,不然會誤判成「失敗被延遲處理」。
這三個訊號證明的是「Tekton 不中止已經在跑的 Task」,僅此而已。 它們偵測不到 §5 前提二那個繞法——在那裡三個訊號全部正常:Cancelled 0、沒有 PipelineRunStopping、skippedTasks 是空的。
兩個並行的 Task 之間沒有任何協調。A 失敗的那一刻,B 可能已經跑完了,而 B 完全不知道 A 出了事。
所以「兩件事互不相干」不足以判斷能不能並行。要問的是:
如果其中一個失敗,另一個已經產生的結果還算不算數?
後面每一節都用這把尺。
前段的掃描與測試不是全部互不依賴,有兩層次序:
git-clone
├─► gitleaks-scan ──────────────────────────────► build-push
├─► sca-scan ───────────────────────────────────► build-push
└─► npm-build
├─► eslint-check ────────────────────────► build-push
└─► semgrep-scan ────────────────────────► build-push
└─► unit-test ────────────────────► build-push
gitleaks-scan、sca-scan、npm-build 直接接在 git-clone 之後,三個並行eslint-check、semgrep-scan 要等 npm-build——沒有 node_modules 就沒有依賴樹可分析unit-test 接在 semgrep-scan 之後全部匯流到 build-push:
- name: build-push
runAfter: [unit-test, eslint-check, gitleaks-scan, semgrep-scan, sca-scan]
runAfter 陣列列出的全部成功,build-push 才會開始。
這裡不需要 1.4 的判斷,因為前段這些 Task 都不產生對外的產物——失敗了就是失敗了。理由比較單純:沒必要花 Registry 空間跟頻寬去存一個已知有問題的映像檔。gitleaks-scan 已經抓到程式碼裡有 API Key,繼續 build 出來只是讓這個產物多存活一段時間。
trivy-scan → cosign-signCosign 簽章的語意是「這個映像檔通過了所有關卡」。任何看到簽章的人都會據此判斷它是安全的。
套用 1.4:如果 trivy-scan 失敗,cosign-sign 已經蓋下去的章還算不算數?
不算。一個帶簽章的高風險映像檔,比一個沒簽章的高風險映像檔更危險——它會通過後面所有依賴簽章判斷信任的機制。
這是語意上的結論,跟兩者跑多久無關。
trivy-scan 7~10 秒
cosign-sign 4~6 秒
即使同時起跑,cosign-sign 幾乎每次都會先跑完。 在 trivy-scan 判定失敗的那個時間點,章十之八九已經簽好了。
這組數字取自另一套已經跑完整條 Pipeline 的舊環境,是跨多次成功執行的比對值。在你自己的環境上會不一樣——image 大小、Trivy 的資料庫更新、Registry 延遲都有影響。要量的話:
& $OC --kubeconfig $kc get taskrun -n ci -l tekton.dev/pipelineRun=<run-name> ` -o custom-columns='TASK:.metadata.labels.tekton\.dev/pipelineTask,START:.status.startTime,END:.status.completionTime'
- name: trivy-scan
runAfter: [build-push]
- name: cosign-sign
runAfter: [trivy-scan]
cosign-sign → update-gitopsupdate-gitops 更新 Git 倉庫,ArgoCD 偵測到變更後開始部署新版 Pod。
如果它跟 cosign-sign 並行,可能出現這個順序:
update-gitops 完成 → ArgoCD 開始部署 → Kyverno 檢查簽章 → 簽章還沒產生 → Pod 被拒絕
Kyverno 在 Pod 建立時攔截並驗證簽章,查不到就拒絕建立:
Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:
resource Pod/frontend-demo/unsigned-test-pod was blocked due to the following policies
verify-frontend-demo-image:
verify-image-signature: 'failed to verify image …:unsigned-test:
.attestors[0].entries[0].keys: no signatures found'
經 ArgoCD / ReplicaSet 呈現時外面可能多包一層 FailedCreate,底層擋下 Pod 的是這則訊息。
這則訊息取自舊環境。 這台目前沒有 Kyverno 也沒有 ArgoCD——全叢集 17 個 admission webhook 全是 Tekton 與 OpenShift 內建的,
clusterpolicyCRD 不存在。這台重現不出來。
會不會出事取決於 cosign-sign 跟 update-gitops 誰先完成。有時候正常、有時候失敗,是最難排查的一類問題——重跑一次可能就過了。
這條 race condition 是依 Kyverno 的驗證時機與 ArgoCD 的同步行為推導的,沒有實際觸發過:舊環境的 DAG 從一開始就把
update-gitops排在cosign-sign之後。上面那則錯誤訊息來自另一次測試——故意推一個未簽章的映像檔,Kyverno 拒絕的格式相同,差別只在原因是「沒人簽」而不是「還沒簽完」。
- name: update-gitops
runAfter: [cosign-sign]
- name: upload-sbom
runAfter: [build-push]
upload-sbom 跟 cosign-sign 並行沒問題。套用 1.4:如果 cosign-sign 失敗,已經上傳的 SBOM 還算不算數?
算。SBOM 描述的是「這個映像檔裡有什麼」,那是事實陳述,不是安全承諾,不因為簽章失敗而失效。
強制 trivy-scan → cosign-sign → update-gitops 之後,可以得到一種間接信任:
Kyverno 驗簽通過時,等於同時確認這個映像檔通過了 Trivy。 因為依 DAG 的順序,沒通過掃描的映像檔走不到簽章那一步。Kyverno 不需要自己去查掃描報告,一個驗簽動作就帶出了前面所有關卡。
這是 SLSA 供應鏈框架的核心思路:下游驗證上游留下的證明,間接信任整條鏈路。
這一段是規劃,不是現況。 這台還沒有 Kyverno,Day 29 才會裝。在那之前,Pipeline 內部的順序就是唯一的檢查——沒有第二道防線可以兜底。這讓兩條硬邊界現在更必要,不是更不必要。
簽章不攜帶「我通過了掃描」這個資訊。 它只證明有人用私鑰簽了這個映像檔。
改一行 runAfter,簽章就不再代表通過掃描——而驗簽照樣會過,不會有任何地方亮紅燈。
這條比前提一隱蔽,因為它不用動 runAfter 一個字。
Tekton 支援把 pipelineTask 或 step 標成 onError: continue(1.23.1 的 enable-api-fields: beta 就有,不需要開 alpha)。
實測:在模擬的 trivy-scan 上加這一行,下游兩個 Task 照原本的 runAfter 串著、完全沒改:
PipelineRun status = True
reason = Succeeded
message = Tasks Completed: 3 (Failed: 1 (Ignored: 1), Cancelled 0), Skipped: 0
skippedTasks = (空)
trivy-scan False FailureIgnored
cosign-sign True Succeeded → 章簽下去了
update-gitops True Succeeded → 部署觸發了
§1.3 那三個訊號全部正常。 Cancelled 是 0、沒有 PipelineRunStopping、skippedTasks 是空的——收工清單裡「看 Cancelled 是不是 0」那一項在這裡毫無作用。
不是 runAfter 這條邊的語意被改了。
Tekton 的「上游失敗就不排下游」建立在**「那次失敗被判定為失敗」上。onError: continue 改的是上游那次執行的分類**,讓它不算失敗——於是停止邏輯根本沒有被觸發。
邊還在,只是沒有東西去撞它。
onError 也可以寫在 step 上:
steps:
- name: scan
onError: continue
PipelineRun reason = Succeeded
message = Tasks Completed: 2 (Failed: 0, Cancelled 0), Skipped: 0
TaskRun True / Succeeded
Failed: 0。 pipelineTask 層至少還留下 (Ignored: 1) 與 FailureIgnored,step 層在 PipelineRun 與 TaskRun 兩層都乾乾淨淨。
唯一的痕跡是:
status.steps[].terminated.exitCode = 5
status.steps[].terminated.reason = Completed ← 還寫著 Completed
要抓它只能逐一翻「成功的」TaskRun 裡每個 step 的 exit code。
runAfter 有效如果下游用 $(tasks.trivy-scan.results.verdict) 取上游的結果,整條 run 直接紅燈:
reason = PipelineValidationFailed
message = Invalid task result reference: Could not find result with name verdict ...
上游被忽略之後不會產出 result,下游的引用就解析不了。
所以繞得過去的,正好就是第 3、4 節那個純 runAfter 的拓撲。 這不是削弱前面的論證——它說明「只用 runAfter 表達依賴」本身就是這個弱點的來源。要讓邊界更硬,可以讓 cosign-sign 去引用 trivy-scan 的 result,把「順序依賴」升級成「資料依賴」。本篇不做,但那是 §5 這個信任真正能被強化的方向。
這兩個前提都是 Pipeline 定義本身需要被保護的理由,也是 Tekton Chains 的 provenance 想解決的問題:把「用什麼流程建構的」也簽進證明裡,讓這個信任從「假設設定是對的」變成可驗證。
光看 YAML 不夠,要確認「該並行的真的並行、該串行的真的串行」。
$OC = (Get-ChildItem "$env:USERPROFILE\.crc\cache" -Recurse -Filter "oc.exe" | Select-Object -First 1).FullName
$kc = "$env:USERPROFILE\.crc\machines\crc\kubeconfig"
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev <pipeline-name> -n ci `
-o jsonpath="{range .spec.tasks[*]}{.name}{' <- '}{.runAfter}{'\n'}{end}"
輸出(節錄本篇相關的部分):
build-push <- ["unit-test","eslint-check","gitleaks-scan","semgrep-scan","sca-scan"]
trivy-scan <- ["build-push"]
upload-sbom <- ["build-push"]
cosign-sign <- ["trivy-scan"]
update-gitops <- ["cosign-sign"]
不要用
.spec.tasks[*].taskRef.name盤點這條 Pipeline 用了哪些 Task。 走 cluster resolver 的 Task 沒有taskRef.name,jsonpath 會直接跳過那一列而不是留空——輸出的筆數比實際少,而且看不出來少了誰。resolver 在 1.23.1 是主流寫法,要盤點得用-o json自己判斷taskRef.name與taskRef.resolver兩種形態。
onError兩層都要查,而且查法不同。
# A. pipelineTask 層
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev <name> -n ci -o json | ConvertFrom-Json |
ForEach-Object { $_.spec.tasks } | Where-Object { $_.onError } |
Select-Object name, onError
# B. step 層(在 Task 定義裡,不在 Pipeline 裡)
((& $OC --kubeconfig $kc get task -n ci -o json) | ConvertFrom-Json).items | ForEach-Object {
$t = $_.metadata.name
$_.spec.steps | Where-Object { $_.onError } | ForEach-Object { "$t / $($_.name) = $($_.onError)" } }
兩條都期望無輸出。B 不能省——step 層的 onError 寫在 Task 裡,查 Pipeline 查不到。
& $OC --kubeconfig $kc get taskrun -n ci -l tekton.dev/pipelineRun=<run-name> `
-o custom-columns='TASK:.metadata.labels.tekton\.dev/pipelineTask,START:.status.startTime,END:.status.completionTime'
舊環境的輸出:
TASK START END
build-push 2026-07-23T15:26:00Z 2026-07-23T15:26:09Z
trivy-scan 2026-07-23T15:26:09Z 2026-07-23T15:26:19Z
upload-sbom 2026-07-23T15:26:09Z 2026-07-23T15:26:15Z
cosign-sign 2026-07-23T15:26:19Z 2026-07-23T15:26:24Z
update-gitops 2026-07-23T15:26:24Z 2026-07-23T15:26:29Z
三件事一次看出來:
build-push 結束的那一秒(15:26:09),trivy-scan 與 upload-sbom 同時起跑 → 該並行的有並行cosign-sign 在 trivy-scan 結束的那一秒(15:26:19)才起跑 → 沒有重疊update-gitops 在 cosign-sign 結束的那一秒(15:26:24)才起跑 → 沒有重疊{range} 語法裡如果有內層雙引號,會被 argv 解析吃掉:
error: error parsing jsonpath ... unclosed action
6.1 那段之所以外層用雙引號、內層用單引號,就是為了避開這個。內層一定要雙引號的時候,改用 -o json | ConvertFrom-Json 自己取——6.2 就是這樣寫的。
# 1. 兩條硬邊界
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev <name> -n ci -o jsonpath="{.spec.tasks[?(@.name=='cosign-sign')].runAfter}"
# 期望 ["trivy-scan"]
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev <name> -n ci -o jsonpath="{.spec.tasks[?(@.name=='update-gitops')].runAfter}"
# 期望 ["cosign-sign"]
# 2. build-push 的匯流沒有漏掉任何前置
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev <name> -n ci -o jsonpath="{.spec.tasks[?(@.name=='build-push')].runAfter}"
# 3A. pipelineTask 層有沒有 onError
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev <name> -n ci -o json | ConvertFrom-Json |
ForEach-Object { $_.spec.tasks } | Where-Object { $_.onError } | Select-Object name, onError
# 3B. step 層有沒有 onError(寫在 Task 裡,查 Pipeline 查不到)
((& $OC --kubeconfig $kc get task -n ci -o json) | ConvertFrom-Json).items | ForEach-Object {
$t = $_.metadata.name
$_.spec.steps | Where-Object { $_.onError } | ForEach-Object { "$t / $($_.name) = $($_.onError)" } }
# 3C. 執行紀錄裡有沒有 FailureIgnored(pipelineTask 層被忽略的痕跡)
((& $OC --kubeconfig $kc get taskrun -n ci -o json) | ConvertFrom-Json).items |
Where-Object { $_.status.conditions[0].reason -eq 'FailureIgnored' } |
ForEach-Object { $_.metadata.name }
# 3D. 「成功」的 TaskRun 裡有沒有非 0 的 step exitCode ← 抓 step 層的唯一辦法
((& $OC --kubeconfig $kc get taskrun -n ci -o json) | ConvertFrom-Json).items |
Where-Object { $_.status.conditions[0].status -eq 'True' } | ForEach-Object {
$n = $_.metadata.name
$_.status.steps | Where-Object { $_.terminated.exitCode -ne 0 } |
ForEach-Object { "$n step=$($_.name) exitCode=$($_.terminated.exitCode)" } }
# 4. 實際執行有沒有重疊
& $OC --kubeconfig $kc get taskrun -n ci -l tekton.dev/pipelineRun=<run-name> `
-o custom-columns='TASK:.metadata.labels.tekton\.dev/pipelineTask,START:.status.startTime,END:.status.completionTime'
# 5. 失敗時的語意(換版本後要重驗)
& $OC --kubeconfig $kc get pipelinerun <run> -n ci -o jsonpath='{.status.conditions[0].message}'
# 看 Cancelled 是不是 0
第 1、3A–3D 項值得排進定期檢查——兩種改法都不會有任何錯誤,Pipeline 照跑、簽章照發、驗簽照過。
第 5 項只驗第 1 節那個語意,不要拿它當防護檢查。 前提二那個繞法之下 Cancelled 一樣是 0,這一項完全看不出異常。
3D 是抓 step 層 onError 的唯一辦法,因為那一層在 PipelineRun 與 TaskRun 的 condition 上都不留痕跡。
判斷兩個 Task 能不能並行,問法不是「它們相不相干」,而是:如果其中一個失敗,另一個已經產生的結果還算不算數?
trivy-scan 失敗,cosign-sign 蓋的章不算數 → 串行cosign-sign 失敗,update-gitops 觸發的部署不算數 → 串行cosign-sign 失敗,upload-sbom 上傳的清單仍然算數 → 並行會需要這樣問,是因為 Tekton 失敗時只擋還沒開始的下游,不中止已經在跑的——這是 upstream v0.14.0 起的設計行為,狀態訊息裡的 Cancelled: 0 與 PipelineRunStopping 是直接證據。
這兩條邊界換來的是「驗簽等於驗證整條鏈」,但有兩個前提:DAG 沒被改,以及關鍵 Task 沒有被標成 onError: continue。
第二個前提的機制值得記清楚:onError 不是改 runAfter 這條邊的語意,是改上游那次執行的分類,讓它不算失敗——於是「上游失敗就不排下游」這條規則根本沒被觸發。邊還在,只是沒有東西去撞它。
而且它寫在 step 層時,PipelineRun 與 TaskRun 兩層都乾乾淨淨,Failed: 0、Succeeded,唯一的痕跡是某個 step 的 terminated.exitCode 不是 0。只檢查 runAfter 驗不到,只看 PipelineRun 的狀態訊息也驗不到。
簽章本身不記錄流程——這是 Tekton Chains 的 provenance 之後要補的洞。
Tekton 上游
| 文件 | 用到的結論 |
|---|---|
| Pipelines | runAfter 的語意;retries 在其他 Task 失敗時仍會執行;onError: continue 造成 FailureIgnored 與 Failed: 1 (Ignored: 1),PipelineRun 整體仍為 Succeeded |
| PipelineRuns | StoppedRunFinally 的定義——已產生的 TaskRun 正常完成、不再排程新的非 finally task。Stopping 一詞的語意來源 |
| v0.14.0 release notes | 第 1 節的依據。Task 失敗時停止排程新 Task、等已排程的完成才標記失敗;PipelineRunStopping 這個 reason 的引入 |
| issue #3119 | pipeline 含 finally 時 PipelineRunStopping 可能不被設定(2020 年回報,1.23.1 是否修復未查) |
| TEP-0058 Graceful PipelineRun Termination | Stopped 與 Cancelled 兩種語意的設計討論 |